Skip to content

#1133 Reject an anchor link that names no heading - #1135

Merged
williamthorsen merged 11 commits into
mainfrom
1133
Jul 31, 2026
Merged

#1133 Reject an anchor link that names no heading#1135
williamthorsen merged 11 commits into
mainfrom
1133

Conversation

@williamthorsen

@williamthorsen williamthorsen commented Jul 31, 2026

Copy link
Copy Markdown
Owner

What

A link pointing at a heading in its own document now fails sync and install when no such heading exists, or when two do. Every path that renders or ships a body carries the check (rulebooks, skills, subagents, and the guidance files install deploys). If the check fails, the deployment is aborted before anything is written.

Why

Splitting a section out of a larger document into a standalone artifact is how a package's reference content gets authored, and it routinely leaves behind anchors that named a heading the split took away. Nothing in the delivery pipeline noticed: the tree-wide test suite resolved fragments only over this repo's own content, so an artifact from any other source shipped its dead links intact and failed at the reader.

Details

🐛 Bug fixes

  • An anchor-only link whose fragment names no heading in its own body, or names more than one, fails wherever that body is rendered or shipped: the rulebook, skill, and subagent renderers, sync's pre-write pass including --dry-run, and the harness and shared guidance files install deploys. The error names the artifact and lists each offending target once.
  • The check reads the include-expanded body ahead of any rewriting, so the verdict is the same on every harness and a heading carrying a {tool:NAME} token is correctly unaddressable rather than resolving on one harness and dying on the other.
  • A code fence that nothing closes fails on its own terms, since everything below it reads as code and no anchor there can be checked.
  • A flat .md file directly under content/skills/ installs rather than aborting the run on the skill-directory probe, and its body is checked on the same terms as every other rendered body.

♻️ Refactoring

  • One heading-slug algorithm serves both the render-time check and the tree-wide link-resolution suite.
  • The per-file shared-guidance install path moves into its own helper.

🧪 Tests

  • The tree-wide link-resolution suite reports an unterminated code fence as a violation in its own right, matching what the render-time check does with the same condition.

📚 Documentation

  • The content specification records the anchor rule, its scope across artifact types, the exemptions for frontmatter, fenced blocks, and inline code spans, the indented-block boundary and why it stands there, that a code span within a heading still anchors, and that a heading carrying a token cannot be anchored at all.

Closes #1133

`assertAnchorsResolve` rejects an anchor-only Markdown link whose fragment names no heading, or more than one, in the same body, listing every distinct offending target in one error keyed to the source file.

The scan blanks a leading frontmatter block and every fenced code block first, so a heading shown inside a fence offers no anchor and a link written in a `description:` is not read as a body link. Heading slugs derive the way GitHub's do, preserving the gap that stripped punctuation leaves between two spaces.

`collectHeadingSlugs` and `normalizeForAnchorScan` are exported alongside the assertion so a caller resolving cross-file fragments shares one slug algorithm.
Rulebook, skill, and subagent bodies are checked for anchor-only link targets before any rewriting, so an anchor naming no heading (or more than one) in the same body fails the run instead of shipping as a dead locator. `sync` inherits the check through its three pre-write render gates, which means a dry run reports it with nothing written.

Each renderer validates its include-expanded text, so an anchor authored in a partial is resolved against the artifact that inlines it. Validating ahead of the rewrite also keeps a heading carrying a `{tool:NAME}` token unaddressable, which it must be: its slug differs per harness, so no single fragment reaches it.
The harness guidance files `install` renders and the shared guidance files it copies are checked for anchor-only link targets that name no heading, so the delivery path with no render gate of its own no longer ships a dead in-body locator.

Both checks run above the dry-run gate, so a preview reports the failure with nothing written. Harness guidance is checked on its include-expanded text, where the render already holds it. Shared guidance ships verbatim, so its check reads the source, and it applies in link mode too: a symlink carries the same dead locator a copy would.
…ution suite

`content-link-resolution.test.ts` derives heading slugs and blanks non-declaring regions through `anchor-resolution` instead of its own copies, so the repo-wide suite and the render-pass gate cannot disagree about what an anchor resolves to. Blanking now covers frontmatter as well as fenced blocks.

The suite keeps its full scope. It reports every violation across the tree at once where the gate throws on the first artifact, and a fragment on a cross-file target is checked nowhere else.
The specification states that an anchor-only link must name exactly one heading in its own body, across rulebooks, skills, subagents, and the guidance files `install` ships, and moves that rule from the test-enforced class to the run-failing one.

It also records what the rule exempts and what it forbids: frontmatter and fenced code blocks count on neither side, an anchor authored in a partial is reported against the artifact that inlines it, and a heading carrying a token cannot be anchored, since its slug differs per harness.
…tal error

A regular `.md` sitting directly under `content/skills/` no longer aborts `install`. The skill-directory probe stats `<entry>/SKILL.md` and read only ENOENT as "not a skill", so a file there raised ENOTDIR and propagated; `isMissingFile` covers both codes and is documented for this exact probe.

With the probe fixed, `installSkillEntry`'s single-file branch is reachable from its caller, so it gains the same anchor check the directory branch runs.
Four shapes of valid Markdown no longer fail the gate. A link target carrying a Markdown link title resolves on its fragment alone. A tilde-delimited fence is blanked alongside a backtick one, and a fence now closes only on a marker of its own character and at least its length. A link inside an inline code span is read as an illustration rather than a request.

A body opening on a thematic break keeps its headings. Frontmatter is recognized by a top-level YAML key between the delimiters rather than by the delimiters alone; a YAML comment cannot serve as the discriminator, since `# Text` is also an ATX heading.

Inline code spans are blanked on the link-scanning side only. A span inside a heading contributes to that heading's slug, which the slugifier reaches by stripping backticks as punctuation, so blanking it there would break every anchor into a heading that names a symbol.

An indented code block stays scanned: telling one from a nested list item takes block-level parsing, and a wrong call would blank a real anchor. The specification records that boundary along with the widened exemptions.

The failure hint now offers an inlined partial as a lead rather than asserting one, and the helper's contract and the specification both name the paths that expand includes. A rulebook body and a shared guidance file are checked as authored, so neither can have a partial behind its error.
…he rest

A fence with no closing run stops the anchor gate for the remainder of the body, and it now fails the run rather than reporting a clean pass. Everything below an open fence reads as code, so no anchor there can be checked, and a silent pass over an unchecked remainder is indistinguishable from a checked one.

The route is a closing run shorter than its opening: a four-backtick fence wrapping a three-backtick example is closed only by four, so the typo lands in exactly the case run-length matching makes expressible.

The fence walk moves into `scanFences`, which returns the blanked lines and the opening run of any fence left open, so the assertion and `normalizeForAnchorScan` share one state machine.

The specification records the failure, and adds the case its exemption sentence left an author to guess at: a code span within a heading is part of that heading's text, so the heading still anchors with its backticks dropped as punctuation.
`content-link-resolution.test.ts` reports a host that leaves a code fence open instead of scanning the blanked remainder and passing. It is the only anchor coverage this repo runs over library artifacts it does not itself deploy, so a dead anchor hidden under an open fence would have reached a consumer's `sync` with nothing failing here first.

`findUnterminatedFence` exposes the verdict `scanFences` already computes, which `normalizeForAnchorScan` discards. The suite reports the fence and moves on to the next host, matching the order the render gate uses: once a fence is open to end of file, the anchors the host appears to carry are not the ones it carries.

The specification's enumeration of run-failing checks lists the fence condition as its own item rather than a parenthetical inside the anchor one.
Installing shared guidance dispatches each file to a dedicated `installSharedGuidanceEntry`, mirroring how support-directory installs dispatch to `installSkillEntry`, and brings `installSharedGuidance` back within the complexity ceiling the lint suite enforces.

The per-file function reports whether the install reached disk, so the manifest's recorded install time holds its previous value when a dry run or a skipped user-modified file writes nothing.
@github-actions

Copy link
Copy Markdown

Dependency audit

Production dependency audit passed.

@williamthorsen williamthorsen self-assigned this Jul 31, 2026
@williamthorsen
williamthorsen marked this pull request as ready for review July 31, 2026 07:26
@williamthorsen williamthorsen changed the title #1133 Fail the run on an anchor link that names no heading #1133 Reject an anchor link that names no heading Jul 31, 2026
@williamthorsen
williamthorsen merged commit 368ac82 into main Jul 31, 2026
3 checks passed
@williamthorsen
williamthorsen deleted the 1133 branch July 31, 2026 07:50
williamthorsen added a commit that referenced this pull request Aug 4, 2026
…e-v0.2.2 codeassembly-v0.4.0 factory-v0.2.2 codeassembly-mcp-v0.2.2

codeassembly-v0.4.0
- #1153 feat: Make codeassembly and kb CLI tools publishable (#1164)
- #1091 fix: Anchor a project-deployed link where its target deploys (#1159)
- #1156 refactor: Rename packages to publishable names (#1157)
- #1152 tooling: Run every test in the default gate, classified by what it reaches (#1155)
- #1110 tooling: Migrate Vitest to nmr's centralized model (#1154)
- refactor: Refine typescript-preferences
- #1150 fix: State doc-description form and make comment mood opt-in (#1151)
- deps: Upgrade first-party linting deps to latest version
- #1137 feat: Establish personal rulebooks for code layout and TypeScript preferences (#1144)
- #1126 feat: Check a package's own guidance content before it ships (#1138)
- refactor: Fix lint
- tooling: Remove redundant lint rules
- #1133 fix: Reject an anchor link that names no heading (#1135)
- #1122 feat: Capture lede decisions as an accumulating corpus (#1132)
- #1125 feat: Honor invocation tokens in rulebook bodies (#1129)
- #1107 feat: Render rulebook links and path tokens per harness (#1124)
- #1115 feat: Sync guidance at build and install so an upgrade cannot leave it stale (#1123)
- #1114 feat: Adopt a dependency's guidance by naming the package (#1121)
- #1088 feat: Deliver project ambient rulebooks per harness (#1113)
- #1087 refactor: Rename the authoring rulebook and mark its enforced rules (#1092)
- #1095 tooling: Move compilation out of the install lifecycle into a bootstrap step (#1102)
- #1094 refactor: Remove the ambient ripgrep dependency from the test suite (#1097)
- deps: Upgrade all deps to latest version
- tooling: Upgrade all deps to latest version & modernize configs
- refactor: Fix lint
- #1077 feat: Show the proposed edit above post-review menu options (#1078)
- #1068 feat: Deliver ambient rulebooks mechanically, retiring GLOBAL.md (#1075)
- #1069 feat: Add a no-second-person rule to the lede-voice doctrine (#1070)
- #1050 feat: Add a redundancy rule to the lede-voice doctrine (#1058)
- #1035 internal: Add lifecycle workspace with the canonical envelope, vocabulary & lane fold (#1049)
- #1006 internal: Extend lifecycle-event instrumentation to five high-traffic skills (#1034)
- #1028 feat: Rule out absence-of-removed-code tests (#1032)
- #114 feat: Let reviewers emit gated insights into review artifacts (#1031)
- #1027 internal: Retire input.received and redundant skill.progress emits (#1030)
- #1026 fix: Name the side effects an approval ask authorizes (#1029)
- #1022 fix: Normalize action and question label identifiers across asks blocks (#1025)

factory-v0.2.2
- #1156 refactor: Rename packages to publishable names (#1157)
- deps: Upgrade all deps to latest version
- #1152 tooling: Run every test in the default gate, classified by what it reaches (#1155)
- #1110 tooling: Migrate Vitest to nmr's centralized model (#1154)
- tooling: Remove redundant lint rules
- deps: Upgrade deps to latest version
- deps: Upgrade all deps to latest version
- deps: Upgrade all deps to latest version
- tooling: Upgrade all deps to latest version & modernize configs
- refactor: Fix lint
- deps: Upgrade all deps to latest version

kb-v0.3.0
- #1153 feat: Make codeassembly and kb CLI tools publishable (#1164)
- #1156 refactor: Rename packages to publishable names (#1157)
- #1152 tooling: Run every test in the default gate, classified by what it reaches (#1155)
- #1110 tooling: Migrate Vitest to nmr's centralized model (#1154)
- tooling: Remove redundant lint rules
- #1095 tooling: Move compilation out of the install lifecycle into a bootstrap step (#1102)
- tooling: Upgrade all deps to latest version & modernize configs
- refactor: Fix lint

codeassembly-lifecycle-v0.2.0
- #1153 feat: Make codeassembly and kb CLI tools publishable (#1164)
- #1156 refactor: Rename packages to publishable names (#1157)
- #1152 tooling: Run every test in the default gate, classified by what it reaches (#1155)
- #1110 tooling: Migrate Vitest to nmr's centralized model (#1154)
- tooling: Remove redundant lint rules
- #1095 tooling: Move compilation out of the install lifecycle into a bootstrap step (#1102)
- tooling: Upgrade all deps to latest version & modernize configs
- #1038 feat: Add the read-only git adapter for worktree and base-branch ground truth (#1059)
- #1051 feat: Bound fold memory and rescan cost with a retention window (#1057)
- #1035 internal: Add lifecycle workspace with the canonical envelope, vocabulary & lane fold (#1049)

codeassembly-mcp-v0.2.2
- deps: Upgrade all deps to latest version
- #1156 refactor: Rename packages to publishable names (#1157)
- #1152 tooling: Run every test in the default gate, classified by what it reaches (#1155)
- #1110 tooling: Migrate Vitest to nmr's centralized model (#1154)
- #1095 tooling: Move compilation out of the install lifecycle into a bootstrap step (#1102)
- tooling: Upgrade all deps to latest version & modernize configs
- refactor: Fix lint

codeassembly-run-core-v0.2.2
- #1156 refactor: Rename packages to publishable names (#1157)
- #1152 tooling: Run every test in the default gate, classified by what it reaches (#1155)
- #1110 tooling: Migrate Vitest to nmr's centralized model (#1154)
- #1095 tooling: Move compilation out of the install lifecycle into a bootstrap step (#1102)
- tooling: Upgrade all deps to latest version & modernize configs
- refactor: Fix lint
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

An anchor-only link naming no heading passes every gate

1 participant